进程调度案例场景分析:为何不能调度? 您所在的位置:网站首页 cbz 汇编 进程调度案例场景分析:为何不能调度?

进程调度案例场景分析:为何不能调度?

2023-06-01 12:40| 来源: 网络整理| 查看: 265

假设Linux内核只有3个内核线程(见图9.15),0号线程创建了内核线程1和内核线程2,它们永远不会退出。当系统时钟中断到来时,时钟中断处理函数会检查是否有进程需要调度。当有进程需要调度时,调度器会选择运行线程1或者线程2。

假设0号线程先运行,那么在这个场景下会发生什么情况? 这是一个有意思的问题,涉及调度器的实现机制、中断处理、内核抢占、新建进程如何被调度、进程切换等知识点。我们只有把这些知识点都弄明白了,才能真正搞明白这个问题。

2.场景分析

这个场景中的主要操作步骤如下。 (1)start_kernel()运行在0号线程里。0号线程创建了内核线程1和内核线程2。函数调用关系是start_kernel()→kernel_thread()→_do_fork()。在_do_fork()函数会创建新线程,并且把新线程添加到调度器的就绪队列中。0号线程创建内核线程1和内核线程2后,进入while死循环,0号线程不会退出,它正在等待被调度出去。 (2)产生时钟中断。处理器采用时钟定时器来周期性地提供系统脉搏。时钟中断是普通外设中断的一种。调度器利用时钟中断来定时检测当前正在运行的线程是否需要调度。 (3)当时钟中断检测到当前线程需要调度时,设置need_resched标志位。 (4)当时钟中断返回时,根据Linux内核是否支持内核抢占来确定是否需要调度,下面分两种情况来讨论。  支持内核抢占的内核:发生在内核态的中断返回时,检查当前线程的need_resched标志位是否置位,如果置位,说明当前线程需要调度。  不支持内核抢占的内核:发生在内核态的中断在中断返回时不会检查是否需要调度。

不支持内核抢占的内核

在不支持内核抢占功能的Linux内核(见图9.16)里,即使0号线程的need_resched标志位置位了,Linux内核也不会调度内核线程1或者内核线程2来运行。只有发生在用户态的中断返回或者系统调用返回用户空间时,才会检查是否需要调度。处理流程如下所示。

(1)发生时钟中断。触发时钟中断时当前进程(线程)有可能在用户态执行,也可能在内核态执行。如果进程运行在用户态时发生了中断,那么会进入异常向量表的el0_irq汇编函数;如果进程运行在内核态时发生了中断,那么会进入异常向量表的el1_irq汇编函数中。在本场景中,因为3个线程都是内核线程,所以时钟中断只能跳转到el1_irq汇编函数里。当进入中断时,CPU会自动关闭中断。 (2)在el1_irq汇编函数里,首先会保存中断现场(也称为中断上下文)到当前进程的栈中,Linux内核使用pt_regs数据结构来实现一个栈框,用来保存中断现场(本节称为pt_regs栈帧)。 (3)中断处理过程包括切换到Linux内核的中断栈、硬件中断号的查询、中断服务程序的处理等,详细分析可以参考本书卷2的2.4节以及2.5节。 (4)当确定当前中断源是时钟中断后,scheduler_tick()函数会取检查当前进程的是否需要调度。如果需要调度,则设置当前进程的need_resched标志位(thread_info中的TIF_NEED_ RESCHED标志位),详细分析请参考8.1.7节。 (5)中断返回。这里需要给中断控制器返回一个中断结束(End Of Interrupt, EOI)信号。 (6)在el1_irq汇编函数直接恢复中断现场,这里会使用0号线程的pt_regs栈框来恢复中断现场。在不支持内核抢占的系统里,el1_irq汇编函数不会检查是否需要调度。在中断返回时,CPU打开中断,然后从中断的地方开始继续执行0号进程。

支持内核抢占的内核

在支持内核抢占功能的Linux内核中,中断返回时会检查当前进程是否设置了need_resched标志位置位。如果置位,那么调用preempt_schedule_irq()函数以调度其他进程(线程)并运行。如图9.17所示,在支持内核抢占的Linux内核中,中断与调度的流程和图9.16略有不一样。在el1_irq汇编函数即将返回中断现场时,判断当前进程是否需要调度。如果需要调度,调度器会选择下一个进程,并且进行进程的切换。如果选择了内核线程1,则从内核线程1的pt_regs栈框中恢复中断现场并打开中断,然后继续执行内核线程1的代码。

3.如何让新进程执行

可能读者对图9.17会有如下疑问:

如果内核线程1是新创建的进程,它的栈应该是空的,那它第一次运行时如何恢复中断现场呢?

如果不能从内核线程1的栈中恢复中断现场,那是不是内核线程1一直在关闭中断的状态下运行? 对于内核线程来说,在创建时会对如下两部分内容进行设置与保存。

进程的硬件上下文。它是保存在进程中的cpu_context数据结构,进程硬件上下文包括X19~X28寄存器、FP寄存器、SP寄存器以及PC寄存器,详见8.1.6节。对于ARM64处理器来说,设置Pc寄存器为ret_from_fork,即指向ret_from_fork汇编函数。设置SP寄存器指向栈的pt_regs栈框。

pt_regs栈框。

上述内存的设置与保存是在copy_thread()函数里实现的。

int copy_thread( ) {       … childregs->pstate = PSR_MODE_EL1h;     p->thread.cpu_context.x19 = stack_start;  p->thread.cpu_context.x20 = stk_sz;  p->thread.cpu_context.pc = (unsigned long)ret_from_fork;  p->thread.cpu_context.sp = (unsigned long)childregs;       … }

stack_start指向内核线程的回调函数,而x20指向回调函数的参数。 在进程切换时,switch_to()函数会完成进程硬件上下文的切换,即把下一个进程(next进程)的cpu_context数据结构保存的内容恢复到处理器的寄存器中,从而完成进程的切换。此时,处理器开始运行next进程了。根据PC寄存器的值,处理器会从ret_from_fork汇编函数里开始执行,新进程的执行过程如图9.18所示。

ret_from_fork汇编函数实现在arch/arm64/kernel/entry.S文件中。

1 ENTRY(ret_from_fork) 2     bl  schedule_tail 3     cbz x19, 1f       // 不是一个内核线程 4     mov x0, x20 5     blr x19 6 1:  get_thread_info tsk 7     b   ret_to_user

在第2行中,调用schedule_tail()函数来对prev进程做收尾工作。在finish_lock_switch()函数里会调用raw_spin_unlock_irq()函数来打开本地中断。因此,next进程是运行在打开中断的环境下的。 在第3行中,判断next线程是否为内核线程。如果next进程是内核线程,在创建时会设置X19寄存器指向stack_start。如果X19的值寄存器为0,说明这个next进程是用户进程,直接跳转到第6行,调用ret_to_user汇编函数,返回用户空间。 在第4~5行中,如果next进程是内核线程,那么直接跳转到内核线程的回调函数里。 综上所述,当处理器切换到内核线程1时,它从ret_from_fork汇编函数开始执行,schedule_tail()函数会打开中断,因此,不用担心内核线程1在关闭中断的状态下运行。另外,此时的内核线程1不会从中断现场返回,因为到目前为止,内核线程1还没有触发任何一个中断。那么,对于0号线程触发的中断现场怎么办呢?中断现场是保存在中断进程的栈里,只有当调度器再一次调度该进程时,它才会从栈中恢复中断现场,然后继续运行该进程。

4.调度的本质

下面是一个常见的思考题。

raw_local_irq_disable() //关闭本地中断 schedule()  //调用schedule()函数来切换进程 raw_local_irq_enable()  //打开本地中断

有读者这么认为,假设进程A在关闭本地中断的情况下切换到进程B来运行,进程B会在关闭中断的情况下运行,如果进程B一直占用CPU,那么系统会一直没有办法响应时钟中断,系统就处于瘫痪状态。 显然,上述分析是不正确的。因为进程B切换执行时会打开本地中断,以防止系统瘫痪。我们接下来详细分析这个问题。 调度与中断密不可分,而调度的本质是选择下一个进程来运行。理解调度有如下几个关键点。

调度的时机,即什么情况下会触发调度。

如何合理和高效选择下一个进程?

如何切换到下一个进程来执行?

下一个进程如何返回上一次暂停的地方?

我们以一个场景为例,假设系统中只有一个用户进程A和一个内核线程B,在不考虑自愿调度和系统调用的情况下,请描述这两个进程(线程)是如何相互切换并运行的。 如图9.19所示,用户进程A切换到内核线程B的过程如下。 (1)假设在T0时刻之前,用户进程A正在用户空间运行。 (2)在T0时刻,时钟中断发生。 (3)CPU打断正在运行的用户进程A,处于异常模式。CPU会跳转到异常向量表中的el0_irq里。在el0_irq汇编函数里,首先把中断现场保存到进程A的pt_regs栈框中。 (4)处理中断。 (5)调度滴答处理函数。在调度滴答处理中,检查当前进程是否需要调度。如果需要调度,则设置当前进程的need_resched标志位(thread_info中的TIF_NEED_RESCHED标志位)。 (6)中断处理完成之后,返回el0_irq汇编函数里。在即将返回中断现场前,ret_to_user汇编函数会检查当前进程是否需要调度。 (7)若当前进程序需要调度,则调用schedule()函数来选择下一个进程并进行进程切换。 (8)在switch_to()函数里进行进程切换。 (9)T1时刻,switch_to()函数返回时,CPU开始运行内核线程B了。 (10)CPU沿着内核线程B保存的栈帧回溯,一直返回。返回路径为finish_task_switch() →el1_preempt()→el1_irq。 (11)在el1_irq汇编函数里把上一次发生中断时保存在栈里的中断现场进行恢复,最后从上一次中断的地方开始执行内核线程B的代码。

从栈帧的角度来观察,进程调度的栈帧变化情况如图9.20所示。

首先,对于用户进程A,从中断触发到进程切换这段时间内,内核栈的变化情况如图9.20左边视图所示,栈的最高地址位于pt_regs栈框,用来保存中断现场。 然后,依次保存el0_irq汇编函数、ret_to_user汇编函数、_schedule()函数、context_switch()函数以及switch_to()函数的栈帧,此时SP寄存器指向switch_to()函数栈帧,这个过程称为压栈。 接下来,切换进程。 switch_to()函数返回之后,即完成了进程切换。此时,CPU的SP寄存器指向了内核线程B的内核栈中的switch_to()函数栈帧。CPU沿着栈帧一直返回,并且恢复了上一次保存在pt_regs栈框的中断现场,最后跳转到内核线程B中断的地方并开始执行,这个过程称为出栈。 综上所述,上述过程有几个比较难理解的地方。

刚切换到CPU运行的进程(next进程),它需要沿着上一次调度时保留在栈中的踪迹一直返回,并且从栈中恢复上一次的中断现场。我们假设只考虑中断导致的调度,对于主动发生调度的情况以及系统调用返回时发生调度的情况,留给读者思考。

next进程需要为刚调度出去的进程(prev进程)做一些收尾工作,比如,调用raw_spin_unlock_irq()来释放锁并打开本地中断,见finish_task_switch()函数。

switch_to()函数是进程切换的场所,对于系统中所有的进程,不管是运行在用户态的用户进程,还是运行在内核态的内核线程,都必须在switch_to()函数里进行进程切换。对于用户进程来说,它必须借助中断或者系统调用陷入内核,才能有机会从switch_to()函数里把自己调度出去,这个过程必然会在栈中留下踪迹。当用户进程需要重新调度执行时,它也必须根据帧栈的回溯返回用户态,才能继续执行进程本身的代码。

以时钟中断驱动的进程切换涉及两种上下文(一个是中断上下文,一个是进程上下文)的保存和恢复。中断上下文保存在中断进程的栈(即pt_regs栈框)中。进程上下文保存在进程的task_struct数据结构里。

最后留给读者一个有意思的思考题:在中断处理函数中能不能调用schedule()函数



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有